<?php

Raise::load('core.RaiseObject');
Raise::load('core.reflection.RaiseVariable');
Raise::load('core.io.file.RaiseFile');
Raise::load('core.dom.RaiseDomNode');
Raise::load('core.io.RaiseBufferStream');

/**
 * RaiseDomParser class
 *
 * @author Sam-Mauris Yong / hellclanner at live dot com
 * @license http://www.opensource.org/licenses/bsd-license New BSD License
 * @package phpraise.core.dom
 * @todo improve parsing with namespace handling
 * @since 1.2
 */
class RaiseDomParser extends RaiseObject {
    
    /**
     * The stream that this RaiseDomParser is parsing
     * @var RaiseStream
     */
    private $stream;
    
    /**
     * Create a new RaiseDomParser with a stream
     * @param RaiseStream $stream 
     */
    public function __construct($stream){
        $this->stream = $stream;
    }

    /**
     * Parse a file and return the DOM tree
     * @param RaiseFile|string $file The file to parse
     * @return RaiseCollection
     * @static
     */
    public static function parseFile($file){
        if(!($file instanceof RaiseFile)){
            $file = new RaiseFile($file);
        }
        $fileStream = $file->stream();
        $parser = new RaiseDomParser($fileStream);
        return $parser->parse();
    }

    /**
     * Parse an entire document and return the DOM tree
     * @param string $doc The document content to parse
     * @return RaiseCollection
     * @static
     */
    public static function parseDocument($doc){
        $doc = trim($doc);
        if(substr($doc, 0, 9) == '<!DOCTYPE' || substr($doc, 0, 5) == '<?xml'){
            // has DOCTYPE, drop it
            $pos = strpos($doc, '>');
            if($pos === false){
                // no ending > so throw exception
                throw new RaiseDOMParseException('Could not find matching closing ">".');
            }
            $doc = substr($doc, $pos);

        }
        return self::parseString($doc);
    }
    
    /**
     * Parse a string
     * @param string $str The string to parse
     * @return RaiseCollection
     * @static
     */
    public static function parseString($str){
        $bufferedString = new RaiseBufferStream($str);
        $parser = new RaiseDomParser($bufferedString);
        $tags = $parser->parse();
        return $tags;
    }
    
    /**
     * Start parsing the straem
     * @return RaiseCollection 
     */
    public function parse(){
        $tags = $this->parseTagsSet();
        return new RaiseCollection($tags);
    }
    
    /**
     * Parse all the tag sets
     * @param integer $endPosition (optional) The end position to stop parsing. If not specified, the end of the stream will be used.
     * @return array 
     */
    private function parseTagsSet($endPosition = null){
        if($endPosition == null || $endPosition > $this->stream->length()){
            $endPosition = $this->stream->length();
        }
        $tags = array();
        while($this->stream->position() < $endPosition){
            $moreTags = $this->parseNextTag($endPosition);  
            if(count($tags) == 1 && $tags[0] == ''){
                $tags = $moreTags;
            }else{
                $tags = array_merge($tags, $moreTags);
            }
        }
        return $tags;
    }
    
    /**
     * Parse and get the CDATA value
     * @return string 
     */
    private function parseGetCDataValue(){
        $start = $this->stream->position();
        $this->parseSkipTo(']]>');
        $end = $this->stream->position() - 3;
        $this->stream->seek($start);
        $value = $this->stream->read($end - $start);
        $this->stream->seek($end + 3);
        return $value;
    }
    
    /**
     * Skip parsing to a particular tag
     * @param string $closeTag The tag to parse to
     */
    private function parseSkipTo($closeTag){
        $tagTempBuffer = null;
        $currentChar = '';
        $firstChar = substr($closeTag, 0, 1);
        
        while($this->stream->position() < $this->stream->length()){
            $currentChar = $this->stream->read(1);
            
            if($tagTempBuffer === null && $currentChar == $firstChar){
               $tagTempBuffer = '';
            }
            if($tagTempBuffer !== null){
                $tagTempBuffer .= strtolower($currentChar);
            }
            
            $tempLength = strlen($tagTempBuffer);
            if($tagTempBuffer != substr($closeTag, 0, $tempLength) || $tempLength > 3){
                // Reset the buffer since it does not match.
                $tagTempBuffer = null;
            }
            
            if ($tagTempBuffer == $closeTag){
                break;
            }
        }
    }
    
    /**
     * Search for and parse the next tag.
     * It will also parse and prepare any lingering text
     * @param integer $endPosition
     * @return RaiseDomElement
     */
    private function parseNextTag($endPosition){
        $tagName = null;
        $currentChar = '';
        $valuesInFront = '';
        $children = array();
        $attributes = array();
        $endTagLength = 0;
        
        // find next tag opening now
        while($this->stream->position() < $endPosition){
            $currentChar = $this->stream->read(1);
            if($tagName === null && $currentChar == '<'){
                $tagName = '';
            }else if($tagName !== null){
                $endTagLength = strlen($tagName) + 3;
                if($currentChar == '['){
                    if($tagName == '![CDATA'){
                        $valuesInFront .= $this->parseGetCDataValue();
                        $tagName = '';
                        break;
                    }
                }
                if($tagName == '<!--'){
                    $tagName = '';
                    $this->parseSkipTo('-->');
                    break;
                }
                if($currentChar == ' ' || $currentChar == '>'){
                    $this->stream->seek($this->stream->position() - 1);
                    $attributes = $this->parseAttributes();
                    $tagName = strtolower($tagName);
                    if($this->stream->read(1) == '>'){
                        // not self closing, parse value
                        $start = $this->stream->position();
                        if($start < $this->stream->length()){
                            $this->reachEndTag($tagName);
                            if(in_array($tagName, array('img', 'link', 'base', 'p', 'br', 'center')) && $this->stream->position() >= $this->stream->length()){
                                $this->stream->seek($start);
                            }else{
                                $end = $this->stream->position() - $endTagLength;
                                $this->stream->seek($start);
                                $children = $this->parseTagsSet($end);
                                $this->stream->seek($end + $endTagLength);
                            }
                        }
                    }else{
                        $this->stream->seek($this->stream->position() + 1);
                    }
                    
                    break;
                }else{
                    $tagName .= $currentChar;
                }
            }else{
                $valuesInFront .= $currentChar;
            }
        }
        $return = array();
        if($valuesInFront){
            $return[] = $valuesInFront;
        }
        if($tagName){
            $tag = $this->createNode($tagName, $children, $attributes);
            $return[] = $tag;
        }
        return $return;
    }
    
    /**
     * Create a node
     * @param string $name The name of the node
     * @param array|RaiseCollection $children The children of the node
     * @param array|RaiseCollection $attributes The attributes of the note
     * @return RaiseDomElement 
     */
    private function createNode($name, $children, $attributes){
        $tag = new RaiseDomNode($name);
        if($tag->children()){
            foreach($children as $child){
                $tag->children()->add($child);
            }
        }
        $tag->attributes(new RaiseCollection($attributes));
        return $tag;
    }
    
    /**
     * Parse till the end of the closing tag
     * @param string $tagName The tag name
     */
    private function reachEndTag($tagName){
        $nestLevel = 0;
        $tagTempBuffer = null;
        $tagTempBuffer2 = null;
        $currentChar = '';
        
        $closeTag = '</' . $tagName . '>';
        $openTag = '<' . $tagName . '';
        $tagNameLength = strlen($closeTag);
        
        while($this->stream->position() < $this->stream->length()){
            $currentChar = $this->stream->read(1);
            
            if($tagTempBuffer === null && ($currentChar == '<')){
               $tagTempBuffer = '';
            }
            if($tagTempBuffer !== null){
                $tagTempBuffer .= strtolower($currentChar);
            }
            if($tagTempBuffer2 === null && ($currentChar == '<')){
               $tagTempBuffer2 = '';
            }
            if($tagTempBuffer2 !== null){
                $tagTempBuffer2 .= strtolower($currentChar);
            }
            
            $tempLength = strlen($tagTempBuffer);
            if($tagTempBuffer != substr($closeTag, 0, $tempLength) || $tempLength > $tagNameLength){
                // Reset the buffer since it does not match the close tag
                $tagTempBuffer = null;
            }
            if(strlen($tagTempBuffer2) > ($tagNameLength - 2)){
                $tagTempBuffer2 = null;
            }
            
            $isCloseTag = ($tagTempBuffer == $closeTag);
            $isOpenTag = ($tagTempBuffer2 == $openTag);
            
            if($isOpenTag){
                $nestLevel++;
            }
            if($nestLevel > 0 && $isCloseTag){
                $nestLevel--;
            }else if ($nestLevel == 0 && ($isCloseTag)){
                break;
            }
        }
    }
    
    /**
     * Parse and return attributes
     * @return array
     */
    private function parseAttributes(){
        $currentChar = '';
        
        $tempAttrName = '';
        $tempAttrValue = '';
        
        $valueQuote = null;
        $escape = false;
        $state = 0; // 0 for parsing attribute name, 1 for parsing attribute value
        
        $attributes = array();
        
        while($this->stream->position() < $this->stream->length()){
            $currentChar = $this->stream->read(1);
            
            /**
             * Check if the character is =, means that the attribute name has reached. 
             */
            if($state == 0){
                if($currentChar == '='){
                    $state = 1;
                // trap either a /> or > when closing the opening tag
                }else if($state == 0 && (($tempAttrName == '/' && $currentChar == '>') || $currentChar == '>')){
                    // now we've reached tag close, and we're not even inside an attribute value.
                    $tempAttrName = trim($tempAttrName);
                    if($tempAttrName != '' && $tempAttrName != '/'){
                        // now that we're parsing the attribute name halfway and the tag close shows up
                        // let's assume it's something like <input checked>
                        $attributes[$tempAttrName] = $tempAttrValue;
                    }
                    if($tempAttrName == '/'){
                        $this->stream->seek($this->stream->position() - 2);
                    }else{
                        $this->stream->seek($this->stream->position() - 1);
                    }
                    $tempAttrName = '';
                    break;
                }else{
                    $tempAttrName .= $currentChar;
                }
            }else{
                if($valueQuote == null){
                    if($currentChar == '"' || $currentChar == ''){
                        $valueQuote = $currentChar;
                    }else{
                        $valueQuote = ' ';
                        $tempAttrValue .= $currentChar;
                    }
                }else if(!$escape && ($currentChar == $valueQuote || ($valueQuote == ' ' && $currentChar == '>'))){
                    $state = 0;
                    // now that we have both the name and value...
                    $tempAttrName = trim($tempAttrName);
                    $attributes[$tempAttrName] = $tempAttrValue;
                    $tempAttrName = '';
                    $tempAttrValue = '';
                    $valueQuote = null;
                    if($currentChar == '>'){
                        $this->stream->seek($this->stream->position() - 1);
                    }
                }else{
                    $tempAttrValue .= $currentChar;
                }
                // check for escape character
                if($currentChar == '\\'){
                    $escape = true;
                }else{
                    // reset escape status
                    $escape = false;
                }
            }
        }
        
        if($tempAttrName || $tempAttrValue){
            throw new RaiseDOMParseException('Unexpected end of stream found: could not find the closing character of an attribute name or value (i.e. \' \' or \'>\' missing).');
        }
        return $attributes;
    }
}